python-ecosys/debugpy: Add VS Code debugging support for MicroPython. - #1022
python-ecosys/debugpy: Add VS Code debugging support for MicroPython.#1022andrewleech wants to merge 32 commits into
Conversation
This implementation provides a Debug Adapter Protocol (DAP) server that enables VS Code to debug MicroPython code with full breakpoint, stepping, and variable inspection capabilities. Features: - Manual breakpoints via debugpy.breakpoint() - Line breakpoints set from VS Code - Stack trace inspection - Variable scopes (locals/globals) - Source code viewing - Stepping (into/over/out) - Non-blocking architecture for MicroPython's single-threaded environment - Conditional debug logging based on VS Code's logToFile setting Implementation highlights: - Uses MicroPython's sys.settrace() for execution monitoring - Handles path mapping between VS Code and MicroPython - Efficient O(n) fibonacci demo (was O(2^n) recursive) - Compatible with MicroPython's limited frame object attributes - Comprehensive DAP protocol support Files: - debugpy/: Core debugging implementation - test_vscode.py: VS Code integration test - VSCODE_TESTING_GUIDE.md: Setup and usage instructions - dap_monitor.py: Protocol debugging utility Usage: ```python import debugpy debugpy.listen() # Start debug server debugpy.debug_this_thread() # Enable tracing debugpy.breakpoint() # Manual breakpoint ``` 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
|
For those interested in AI coding, this was 95% written by Claude Code (Opus and Sonnet 4) as mentioned in the attributions above. I started prompting the build of this just this morning at 5am from my armchair with my infant asleep in my arms, using Termux on my phone to ssh into my linux box. I had a clone of micropython with my historical work on getting pdb checked out (micropython/micropython#8767 and #499) I also had a copy of the official cpython debugpy package checked out, this is the package used behind the scenes to drive python debugging in VSCode and similar IDE's. From past reviews I knew debugpy relies on threads, the old pydevd network debug engine as well a large RPC server which runs in a thread. I figured some of this could be re-implemented if needed, or replaced with other servers already running on micropython :-) So I started with a Which produced DEBUGPY_ARCHITECTURE_ANALYSIS.md I then kicked off : WIthin just 1 hour of armchair vibe coding I had an initial implementation ready to test, along with test scripts and a written plan. Around 10 am I was at my desk and had finished my morning meetings, so started testing it in the background while working on my other "real" projects. It tooks quite a few iterations of testing in vscode for Claude to finish its implementation plan, adding features as it ran test scripts with me hitting the vscode "debug" button in between. Most of these tests failed badly in many different ways, enough that I was quite pessimistic at times because it really looked like it wasn't going to work ..... however I was still able to get other solid work done though during this time (which coincidentally was also using Claude Code; I've had 4 sessions actively on the go today) so I gave Claude a few chances to get it all going after a number of wrong paths were backtracked. After all that though this screen capture was at 2:12 pm (and yes I ate lunch during that time too): It took me a while to realise while reviewing afterwards and cleaning up the git tree that it hasn't actually pulled in |
|
Thanks Andrew, |
| "configurations": [ | ||
| { | ||
| "name": "Attach to MicroPython", | ||
| "type": "python", |
There was a problem hiding this comment.
Thanks, yeah I fixed that in the examples file, missed it here
| continue | ||
|
|
||
| try: | ||
| value_str = str(value) |
There was a problem hiding this comment.
I think this should be value_str = repr(value) . without that strings show without quotes etc.
|
Thanks @Josverl good to hear it either for you, I still could hardly believe it worked for me! I'd be interested to hear any notes about what was confusing / difficult to get going to feed into docs. I assume some of it was getting paths right to import stuff? And/or compiling with the other features needed? Aka things that'll be better once finished and merged... I will do some testing on hardware too, ensure that does work and document how to get it going. I did think the branches were pretty well rebased up to date, I'll double check. Oh yeah I'll eventually look into getting a useful representation of locals too, even if they end up basically just showing the array of values without names as per the current internal representation. |
| 1. Build the MicroPython Unix coverage port: | ||
| ```bash | ||
| cd ports/unix | ||
| make CFLAGS_EXTRA="-DMICROPY_PY_SYS_SETTRACE=1" |
There was a problem hiding this comment.
this flag MICROPY_PY_SYS_SETTRACE conflicts with the referenced PR where this is already set unconditionally
I first got in a tangle by
blocked most attempts at building as Building a firmware with "the updates to settrace in micropython/micropython#8767" Matching up the paths vscode / remote paths, was not to difficult. Open questions/ more play time needed :
📚 likely relevant : What is the Debug Adapter Protocol? |
|
@andrewleech |
Yes the automatic make submodules in mpbuild pretty much breaks development within micropython-lib, I haven't figured out any way to resolve this without a newer/smarter algorithm in the make submodules target (which I haven't written yet)
Ah yeah, the readme in micropython-lib doesn't necessarily know about the updates in the micropython PR,
I haven't figured out the read only src either, it's annoying though. I think it's related to path matching issue between local and remote copies.
Not sure if it is really needed?
The path matching would need to be smart enough to match PC side py to remote module (should be possible) and the mpy would need to have been made with opt=0 otherwise line numbers are stripped.
Ah yeah I didn't actually test that!
Yeah that would be helpful, I'll add it
Definitely a good reference to add to the docs thanks |
I did some work to terminate on disconnect |
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
When breakpoints are hit, VS Code was opening read-only copies of source files instead of the original workspace files due to path mismatches between VS Code's absolute paths and MicroPython's runtime paths. Changes: - Add path mapping dictionary to track VS Code path <-> runtime path relationships - Enhance breakpoint matching to handle relative paths and basename matches - Update stack trace reporting to use mapped VS Code paths - Add debug logging for path mapping diagnostics - Fix VS Code launch configuration (debugpy -> python, enable logging) This ensures VS Code correctly opens the original editable source files when debugging, rather than creating read-only temporary copies. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Andrew Leech <andrew.leech@planetinnovation.com.au>
|
I was able to add (a first rough draft) of support for |
Oh wow awesome, I wanted to tackle basically exactly that! Looks great :-D |
|
Now also able to resolve the names of local variables, at the cost of some memory per frame, and a change in the compiler. Still a lot of checking and cleanup to do in that part of the code though |
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Store both folder mappings from the debugger, and 1:1 file mappings . Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com> (cherry picked from commit 215300dad99c2dab2adbf8d48e7044508b17b3e9) Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
Signed-off-by: Jos Verlinde <jos_verlinde@hotmail.com>
recv_message() stripped the header from the receive buffer as soon as the CRLF/CRLF terminator was found, but only persisted buffer state on some partial-read paths. When a message body arrived in a later read than its header, the parsed-header state was lost and framing desynchronised for the rest of the connection. Keep the header and body together in the buffer until the whole message (header + Content-Length bytes) is present, then slice it off. Treat an empty recv as a peer close and EAGAIN/EWOULDBLOCK as "try later". Claude-Session: https://claude.ai/code/session_013VDeuZRScEaKtvZzn2ehyq
- wait_for_client() blocks until the DAP client sends configurationDone, draining the socket so breakpoints set beforehand are honoured; replaces a fixed sleep. Bounded timeout, logged rather than silent. - Runtime capability probe (settrace / save_names / set_local / f_back) derived by exercising the interpreter, never inferred from a build or variant name; exposed via get_capabilities(). - Local variables are marked read-only (DAP presentationHint) when the firmware lacks frame._set_local, so clients do not offer an edit that cannot work; globals stay editable. - listen() resolves the actually-bound port and never advertises port 0. Claude-Session: https://claude.ai/code/session_013VDeuZRScEaKtvZzn2ehyq
debugpy DAP server (PR micropython#1022) MBM-PR: 1022 MBM-URL: micropython#1022
DAP `evaluate` requests carry a `context` field (`watch`, `hover`, `repl`, `clipboard`, ...) that `_handle_evaluate` read but discarded, so every request went through `eval()` only; a statement such as `x = 5` or `def f(): ...` typed into the Debug Console failed with a syntax error instead of running. `evaluate_expression` now dispatches on `context`: `watch`/`hover` (and any other or absent context) keep the original eval-only, read-only contract unchanged. `repl`/`clipboard` try `eval()` first, so a plain expression like `1 + 1` still returns a value, and only fall back to `exec(expression, globals_dict)` when `eval()` raises `SyntaxError`. The exec namespace is globals-only, on purpose: `exec(code, g, l)` binds a top-level assignment into `l`, and here `l` is a throwaway copy of the paused frame's `f_locals` snapshot handed back to the caller and then discarded, so the assignment would silently vanish instead of taking effect. Passing only `globals_dict` makes a statement's assignments land in the running module namespace, where they are visible to the target program after `continue`. That globals-only exec creates a shadowing hazard: assigning a name that is also a LOCAL of the paused frame changes the global but leaves the local exactly as it was, which looks like a no-op from the Debug Console's perspective. `_shadowed_local_warning` detects the common case (a simple `name = ...` or `name op= ...` at the start of the statement) and appends a warning to the result so the mismatch is visible rather than silently misleading; it does not attempt to parse multi-target assignment, unpacking, attribute/subscript targets, or `def`/`class`/`for` bindings, and a `None` result from `_assigned_name` means "not proven safe", never "proven no shadowing".
debugpy DAP server (PR micropython#1022) MBM-PR: 1022 MBM-URL: micropython#1022
debugpy DAP server (PR micropython#1022) MBM-PR: 1022 MBM-URL: micropython#1022
listen() bound the socket, blocked in accept() and handled the client's initialize request before returning, so a caller could only learn the endpoint after a client had already connected to it - unusable for any orchestration that has to read the address in order to attach. listen() now returns as soon as the socket is bound. The accept and the initialize handshake move into wait_for_client(), which creates the session. This matches CPython debugpy, where listen() reports the endpoint and wait_for_client() blocks. port=0 now raises instead of substituting DEFAULT_PORT when the target's getsockname() cannot report the assigned port: callers act on the returned endpoint, so naming an address the socket is not bound to sends them somewhere nothing is listening. Signed-off-by: Andrew Leech <andrew.leech@planetinnovation.com.au> Claude-Session: https://claude.ai/code/session_01PxZTAGYHMm6i8CUF4tW885
debugpy DAP server (PR micropython#1022) MBM-PR: 1022 MBM-URL: micropython#1022
process_pending_messages() set a 1 ms socket timeout and restored blocking mode in its finally. The trace function calls it on entry to every new frame, so handling a message re-enters it, and the inner call's finally put the socket back into blocking mode underneath the outer loop. That loop's next recv() then waited for a message the client will not send until it has seen an event the loop itself is what produces - a deadlock between the two sides. It only bites when the clobber lands inside the window after configurationDone, which is why it presented as a load-sensitive flake: the session hangs before wait_for_client() returns, so the target never runs and no stopped event is ever produced. The nesting is tracked rather than the timeout saved and restored, because MicroPython sockets have no gettimeout(). Measured on the wrapper repo's harness: the previously worst-affected file went from 4 clean runs in 6 to 6 in 6, and the full suite from 0 clean in 3 to 3 in 4. Signed-off-by: Andrew Leech <andrew.leech@planetinnovation.com.au> Claude-Session: https://claude.ai/code/session_01PxZTAGYHMm6i8CUF4tW885
debugpy DAP server (PR micropython#1022) MBM-PR: 1022 MBM-URL: micropython#1022
|
Pushed an update folding in the enhancement lineage that had been developed alongside this branch, plus several fixes found while building tooling on top of it. Fast-forward, no history rewritten. Two are correctness fixes that affect anyone using this today: Nested message pumps deadlocked the session.
Also included: DAP messages split across Exercised by a host-side DAP harness (a fake VS Code client driving real sessions against a built unix firmware): 278 passed. Happy to split any of this out if you'd rather review it separately. |


This implementation provides a Debug Adapter Protocol (DAP) server that enables VS Code to debug MicroPython code with full breakpoint, stepping, and variable inspection capabilities.
Features:
Implementation highlights:
Files:
Usage:
🤖 Generated with Claude Code
Currently only tested on unix port with updates to settrace in micropython/micropython#8767
Should work on any network enabled device however?